iT邦幫忙

2022 iThome 鐵人賽

DAY 29
0
Software Development

歡迎來到 GIS 的世界!30 天從後端開始學 GIS系列 第 29

旅遊規劃小專案 7 - 景點和旅館的交集

  • 分享至 

  • xImage
  •  

文章同步發表至 Medium

最後一個想要建立的功能,是可以在網頁上搜尋景點附近的住宿位置。

  • 身為一個使用者,我可以點選景點開啟詳細資訊,並查詢不同公尺範圍內的住宿。
  • 身為一個使用者,我可以在地圖上看到我所查詢的住宿位置。

API

針對第一點,後端要做的事情有:

  1. 找出景點的資料
  2. 建立一個 Buffer
  3. 和住宿的清單做比對

這裡要注意的地方是,Buffer 的建立在 TWD97 的座標系統底下才會是以公尺為單位,所以在建立 Buffer 和比對清單之前,要記得把景點和住宿的座標系統都換成 TWD97 喔。

[HttpGet("{meter:int}")]
public IActionResult SearchByScenicSpot(int meter, string scenicSpot)
{
    // 利用景點代碼找景點的資料
    var scenicSpot = _db.ScenicSpots.Find(scenicSpot);
    
    // 處理找不到景點的情況
    if (scenicSpot == null) throw new KeyNotFoundException($"找不到代碼為 {scenicSpotId} 的景點");

    // !!!
    // TWD97 座標系統底下的 Buffer 單位才是公尺
    var twd97Geom = ConvertFromWgs84ToTwd97(scenicSpot.Geom);
    var buffer = twd97Geom.Buffer(meter);

    var hotelInfos = _hotelRepo.Get()
        .Where(h => ConvertFromWgs84ToTwd97(h.Geom).Within(buffer))
        .Select(s => new HotelInfo()
        {
            Id = s.Id,
            Name = s.Name,
            Telephone = s.Telephone,
            Address = s.Address,
            X = s.Geom.Centroid.X,
            Y = s.Geom.Centroid.Y,
            Type = s.Type ?? "未分類",
            RoomCount = s.RoomCount,
            LowestPrice = s.LowestPrice,
            CeilingPrice = s.CeilingPrice,
            Email = s.Email,
            ParkingCount = s.ParkingCount
        }).ToList();

    var hotelsAroundScenicSpot = new HotelsAroundScenicSpot()
    {
        Infos = hotelInfos,
        ScenicSpot = new ScenicSpotInfo()
        {
            X = scenicSpot.X,
            Y = scenicSpot.Y,
        },
    };
    return Ok(hotelsAroundScenicSpot);
}

// 詳細情形可以參考第 16 天的內容
private Geometry ConvertFromWgs84ToTwd97(Geometry wgs84Geom)
{
    var wgs84Wkt = "GEOGCS[\"WGS84\",DATUM[\"WGS_1984\",SPHEROID[\"WGS84\",6378137,298.257223563,AUTHORITY[\"EPSG\",\"7030\"]],AUTHORITY[\"EPSG\",\"6326\"]],PRIMEM[\"Greenwich\",0,AUTHORITY[\"EPSG\",\"8901\"]],UNIT[\"degree\",0.01745329251994328,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"4326\"]]";
    var twd97Wkt = "PROJCS[\"TWD97 / TM2 zone 121\",GEOGCS[\"TWD97\",DATUM[\"Taiwan_Datum_1997\",SPHEROID[\"GRS 1980\",6378137,298.257222101],TOWGS84[0,0,0,0,0,0,0]],PRIMEM[\"Greenwich\",0],UNIT[\"degree\",0.0174532925199433,AUTHORITY[\"EPSG\",\"9122\"]],AUTHORITY[\"EPSG\",\"3824\"]],PROJECTION[\"Transverse_Mercator\"],PARAMETER[\"latitude_of_origin\",0],PARAMETER[\"central_meridian\",121],PARAMETER[\"scale_factor\",0.9999],PARAMETER[\"false_easting\",250000],PARAMETER[\"false_northing\",0],UNIT[\"metre\",1],AXIS[\"Easting\",EAST],AXIS[\"Northing\",NORTH],AUTHORITY[\"EPSG\",\"3826\"]]";
    var coordinateSystemFactory = new CoordinateSystemFactory();
    var coordinateTransformationFactory =  new CoordinateTransformationFactory();

    var twd97 = coordinateSystemFactory.CreateFromWkt(Twd97Wkt);
    var wgs84 = coordinateSystemFactory.CreateFromWkt(Wgs84Wkt);

    var fromCoordinateSystems = coordinateTransformationFactory.CreateFromCoordinateSystems(wgs84, twd97);
    return Transform(wgs84Geom, fromCoordinateSystems.MathTransform).Centroid;
    }

static Geometry Transform(Geometry geom, MathTransform transform)
{
    geom = geom.Copy();
    geom.Apply(new MTF(transform));
    return geom;
}

sealed class MTF : ICoordinateSequenceFilter
{
    private readonly MathTransform _mathTransform;

    public MTF(MathTransform mathTransform) => _mathTransform = mathTransform;

    public bool Done => false;
    public bool GeometryChanged => true;
    public void Filter(CoordinateSequence seq, int i)
    {
        double x = seq.GetX(i);
        double y = seq.GetY(i);

        _mathTransform.Transform(ref x, ref y);

        seq.SetX(i, x);
        seq.SetY(i, y);
    }
}

呈現住宿地點

HTML 的部分只需要另外加上一顆按鈕就可以了:

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>旅遊規劃</title>
    <link rel="stylesheet" href="https://unpkg.com/leaflet@1.8.0/dist/leaflet.css"
          integrity="sha512-hoalWLoI8r4UszCkZ5kL8vayOGVae1oxXe/2A4AO6J9+580uKHDO3JdHb7NzwwzK5xr/Fs0W40kiNHxM9vyTtQ=="
          crossorigin=""/>
    <link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.4.1/dist/MarkerCluster.Default.css">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-EVSTQN3/azprG1Anm3QDgpJLIm9Nao0Yz1ztcQTwFspd3yD65VohhpuuCOmLASjC" crossorigin="anonymous">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.9.1/font/bootstrap-icons.css">
    
    <style>
        body{
            height: 100vh;
        }
        
        #app, .row, #map{
            height: 100%;
        }
        
        #search-by-county{
            z-index: 1001;
            top: 1%;
            right: 0;
        }
        
        #search-hotel{
            z-index: 1001;
            top: 10%;
            right: 0;
        }
    </style>
</head>
<body>

<div id="app" class="container-fluid">
    <div class="row align-items-center position-relative">
        <div id="map"></div>
        <div id="search-by-county" class="position-absolute col-auto">
            <button type="button" class="btn btn-lg btn-primary"
                    @click.prevent="searchScenicByCounty">
                查詢
            </button>
        </div>
        <div id="search-hotel" class="position-absolute col-auto">
            <button type="button" class="btn btn-lg btn-primary" 
                    @click.prevent="showList">
                清單
            </button>
        </div>
    </div>
</div>

<script src="https://unpkg.com/leaflet@1.8.0/dist/leaflet.js"
        integrity="sha512-BB3hKbKWOc9Ez/TAwyWxNXeoV9c1v6FIeYiBieIWkpLjauysF18NzgR1MBNBXf8/KABdlkX68nAhlwcDFLGPCQ=="
        crossorigin=""></script>
<script src="https://unpkg.com/leaflet.markercluster@1.4.1/dist/leaflet.markercluster.js"></script>
<script src="https://unpkg.com/axios/dist/axios.min.js"></script>
<script src="https://unpkg.com/vue@3"></script>
<script src="https://cdn.jsdelivr.net/npm/sweetalert2@11"></script>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-MrcW6ZMFYlzcLA8Nl+NtUVF0sA7MsXsP1UyJoMp4YLEuNSfAP+JcXn/tWtIaxVXM" crossorigin="anonymous"></script>

</body>
</html>

查詢介面

至於 JavaScript 的部分,我們一樣利用 Sweet Alert 製作選單,只不過這次是兩層的,第一層是一個下拉式選單,可以選擇要查詢的景點:

第二層則是讓使用者輸入要查詢的範圍:

const app = Vue.createApp({
    data(){
        return{
            map: null,
            oldScenicSpotMarkers: null,

            // 把景點和住宿的 markers 分開來
            oldHotelMarkers: null,

            // 下拉式選單的景點內容
            list: null,

            // 以查詢景點為中心畫出來的圓形 Buffer
            circle: null
        }
    },
    methods:{
        initMap(){
            // ...
        },
        resetScenicSpotMarkers(data){
            // 把 this.oldMarkers 修改成 this.oldScenicSpotMarkers
        },
        resetHotelMarkers(data, meter){
            // 如果已經查詢過就重置
            if (this.oldHotelMarkers !== null) this.map.removeLayer(this.oldHotelMarkers);
            if (this.circle !== null) this.map.removeLayer(this.circle);

            this.oldHotelMarkers = L.markerClusterGroup();

            // 設定住宿的 icon
            let myIcon = L.icon({
                iconUrl: './house-door.svg',
                iconSize: [38, 95]
            });
            data.infos.map(ele => { this.oldHotelMarkers.addLayer(L.marker([ele.y, ele.x], {icon: myIcon})) });
            this.map.addLayer(this.oldHotelMarkers);

            // 設定圓形的 Buffer
            this.circle = L.circle([data.scenicSpot.y, data.scenicSpot.x], {
                color: '#ff6d6d',
                fillColor: '#ff6d6d',
                fillOpacity: 0.1,
                radius: meter
            }).addTo(this.map);
        },
        getScenicSpots(){
            // ..
        },
        showList(){
            let options = this.list.reduce((obj, item) => ({...obj, [item.id]: item.name}), {});
            Swal.fire({
                title: '尋找景點附近的住宿',
                input: 'select',
                inputOptions: options,
                showCancelButton: true,
                confirmButtonText: '下一步',
                cancelButtonText: '取消'
            }).then((result) => {
                if (result.isConfirmed) {
                    // 如果點選下一步,繼續輸入搜尋範圍
                    Swal.fire({
                        title: '請輸入搜尋範圍(公尺)',
                        input: 'text',
                        showCancelButton: true,
                        confirmButtonText: '查詢',
                        cancelButtonText: '取消'
                    }).then((res2) => {
                        if (res2.isConfirmed) {
                            // 如果點擊查詢,call API
                            axios({
                                method: 'get',
                                url: `./api/Hotel/${res2.value}?scenicSpot=${result.value}`
                            }).then(res => {
                                this.resetHotelMarkers(res.data, res2.value);
                            }).catch(err => {
                                console.log(err);
                            })
                        }
                    })
                }
            })
        },
        searchScenicByCounty(){
            // ...
        },
    },
    mounted(){
        this.initMap();
        this.getScenicSpots();
    }
});
app.mount('#app');

最後渲染出來的結果如下,粉紅色的房子是住宿,藍色的點則是原本的景點位置。

Reference


上一篇
旅遊規劃小專案 6 - 重新渲染景點
下一篇
歡迎來到 GIS 的世界 - 沒有終點的旅程
系列文
歡迎來到 GIS 的世界!30 天從後端開始學 GIS30
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言